Skip to content

fix(relay): harden pair body limit, challenge rate-limit, allowlist parse (#61) - #64

Merged
rfxlamia merged 3 commits into
mainfrom
fix/61-relay-p2-hardening
Jul 23, 2026
Merged

fix(relay): harden pair body limit, challenge rate-limit, allowlist parse (#61)#64
rfxlamia merged 3 commits into
mainfrom
fix/61-relay-p2-hardening

Conversation

@rfxlamia

@rfxlamia rfxlamia commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Summary

Closes #61 — relay P2 hardening from M3.2 security audit:

  • REL-1: Add bodyLimit (MAX_ENVELOPE_WIRE_BYTES, 64 KiB) to POST /pair/:sessionId with 413 payload_too_large (Appendix C.4).
  • REL-2: Rate-limit unauthenticated inbox challenge issuance (separate bucket from POST; authenticated pulls unaffected).
  • REL-3: Fail-closed isSenderAllowed when allowed_json is malformed or non-array (deny instead of 500).

Follow-up fix: initial REL-2 middleware on all GET /inbox shared the POST rate-limit bucket, causing e2e session_open to fail with masked relay_unavailable (429). Scoped limit to challenge minting only.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature
  • Breaking change
  • Refactor
  • Documentation
  • Workflow / CI

Test plan

  • pnpm test — 796 passed
  • pnpm --filter @agentpair/relay typecheck — clean
  • biome check on changed files — clean

New coverage:

  • pair.test.ts — oversize POST → 413; exact 64 KiB → 204
  • inbox.test.ts — challenge issuance → 429; POST not blocked when challenge bucket exhausted
  • allowlist.test.ts — corrupt/non-array JSON fail-closed; POST inbox → 403 (not 500)

Pre-flight Checklist

  • Changes scoped to issue acceptance criteria only
  • No secrets or credentials committed
  • Conventional commit messages with issue reference
  • Tests added for all three fixes + rate-limit regression

Summary by CodeRabbit

  • New Features
    • Added rate limiting for inbox challenge issuance, with clear 429 responses when limits are exceeded.
    • Added a maximum payload size for pair requests; oversized submissions return 413.
  • Bug Fixes
    • Malformed or invalid allowlists now fail safely and reject unauthorized requests.
  • Refactor
    • Improved reuse and consistency of rate-limiting behavior across routes.

rfxlamia added 2 commits July 23, 2026 08:19
…arse (#61)

Close P2 availability gaps from M3.2 audit: bound POST /pair body size,
rate-limit GET inbox challenge issuance, and fail-closed allowlist JSON parse.
)

GET inbox middleware shared the POST rate-limit bucket, so e2e suites
exhausted the limit and session_open saw relay_unavailable (429 masked).
Scope REL-2 to unauthenticated challenge minting with a separate bucket.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@rfxlamia, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 22 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2f776a71-74a0-41e3-bb74-875b7b57b406

📥 Commits

Reviewing files that changed from the base of the PR and between 75a48d4 and 99e0c86.

📒 Files selected for processing (1)
  • packages/relay/src/routes/inbox.test.ts
📝 Walkthrough

Walkthrough

Relay hardening adds a reusable rate-limit consumer, isolates inbox challenge issuance throttling, makes malformed allowlists fail closed, and limits POST /pair/:sessionId payload size with boundary coverage.

Changes

Relay rate-limit and challenge flow

Layer / File(s) Summary
Reusable rate-limit consumer
packages/relay/src/middleware/rate-limit.ts
Rate-limit bucket tracking, eviction, window resets, and request decisions are encapsulated in RateLimitConsumer; the middleware delegates consumption to it.
Inbox challenge throttling
packages/relay/src/routes/inbox.ts, packages/relay/src/server.ts, packages/relay/src/routes/inbox.test.ts
Inbox challenge issuance uses a dedicated consumer and returns 429 rate_limit_exceeded after the configured limit; server wiring and isolated-bucket tests are updated.
Fail-closed allowlist parsing
packages/relay/src/routes/allowlist.ts, packages/relay/src/routes/allowlist.test.ts
Malformed or non-array stored allowlists are rejected, with direct parsing and inbox rejection tests.
Pair payload enforcement
packages/relay/src/routes/pair.ts, packages/relay/src/routes/pair.test.ts
POST /pair/:sessionId rejects payloads above MAX_ENVELOPE_WIRE_BYTES with 413 payload_too_large while accepting the exact limit.

Estimated code review effort: 3 (Moderate) | ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main relay hardening changes: pair body limits, challenge rate limiting, and allowlist parsing.
Linked Issues check ✅ Passed Implements all three #61 requirements: pair body limit, challenge-issuance rate limiting, and fail-closed allowlist parsing, with tests.
Out of Scope Changes check ✅ Passed Changes stay focused on relay hardening and supporting tests/refactor; no unrelated scope appears introduced.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/61-relay-p2-hardening

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
packages/relay/src/routes/inbox.test.ts (1)

2639-2661: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover authenticated-pull isolation directly.

This test exhausts challenge issuance and verifies POST remains available, but never performs a signed GET /inbox/:agentId. Obtain Bob’s challenge, exhaust the shared challenge bucket using another agent ID, then assert Bob’s signed pull still returns 200.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/relay/src/routes/inbox.test.ts` around lines 2639 - 2661, Update the
test “does not count authenticated inbox pulls against POST rate limit bucket”
to obtain Bob’s challenge, exhaust the shared challenge bucket with requests for
a different agent ID, then perform a signed GET /inbox/:agentId for Bob and
assert it returns 200. Preserve the existing POST availability assertion while
adding direct coverage that authenticated pulls are isolated from challenge rate
limiting.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/relay/src/routes/inbox.test.ts`:
- Around line 2639-2661: Update the test “does not count authenticated inbox
pulls against POST rate limit bucket” to obtain Bob’s challenge, exhaust the
shared challenge bucket with requests for a different agent ID, then perform a
signed GET /inbox/:agentId for Bob and assert it returns 200. Preserve the
existing POST availability assertion while adding direct coverage that
authenticated pulls are isolated from challenge rate limiting.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3fce355f-7cc3-42e5-85b1-7be200ca8167

📥 Commits

Reviewing files that changed from the base of the PR and between 44abbfc and 75a48d4.

📒 Files selected for processing (8)
  • packages/relay/src/middleware/rate-limit.ts
  • packages/relay/src/routes/allowlist.test.ts
  • packages/relay/src/routes/allowlist.ts
  • packages/relay/src/routes/inbox.test.ts
  • packages/relay/src/routes/inbox.ts
  • packages/relay/src/routes/pair.test.ts
  • packages/relay/src/routes/pair.ts
  • packages/relay/src/server.ts

Comment thread packages/relay/src/routes/inbox.test.ts Outdated
expect(body.error).toBe("rate_limit_exceeded");
});

it("does not count authenticated inbox pulls against POST rate limit bucket", async () => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The test is named "does not count authenticated inbox pulls against POST rate limit bucket" but never performs a signed GET /inbox/:agentId. It only exhausts the challenge-issue bucket and asserts POST is not 429. That covers challenge-vs-POST isolation (the e2e regression vector) but does not lock in the second-order fix: authenticated pulls must remain unthrottled after challenge minting is exhausted. A future change that re-applies middleware rate-limit to the whole GET handler could pass this unit test while re-breaking session_open.

Suggestion: Rename to reflect what is asserted (challenge bucket does not share state with POST), and add a case that: (1) obtains a valid challenge+sig for Bob, (2) exhausts challenge issuance (optionally via another agent id), (3) performs Bob’s signed GET and expects 200 (not 429).

Comment thread packages/relay/src/routes/inbox.test.ts Outdated
});
const port = 13013;
const base = `http://127.0.0.1:${port}`;
const server = serve({ fetch: relay.app.fetch, port });

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This test calls serve({ fetch, port }) without waiting for the listen callback, then immediately fetches. Sibling suites (including the beforeAll in the same describe at line 2610–2612) wait on the listen resolve to avoid ECONNREFUSED races under load.

Suggestion: Mirror the beforeAll pattern: await new Promise<void>((resolve) => { server = serve({ fetch: relay.app.fetch, port }, resolve); }) before the first request.

routes.post(
"/pair/:sessionId",
bodyLimit({
maxSize: MAX_ENVELOPE_WIRE_BYTES,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Pair body limit correctly uses MAX_ENVELOPE_WIRE_BYTES (64 KiB) and payload_too_large, matching Appendix C.4 “other routes.” Inbox still keeps a second app-level UTF-8 length check after bodyLimit; pair relies solely on Hono’s middleware. Under Node’s Content-Length-framed body this is fine, and tests cover oversize/exact boundaries.

Suggestion: Optional defense-in-depth: after c.req.text(), reject if utf8ToBytes(messageJson).length > MAX_ENVELOPE_WIRE_BYTES (same pattern as inbox). Only worth it if you want parity with inbox’s double gate; not required for #61 acceptance.

#64)

Address PR review: wait for server listen, exhaust challenge bucket via
another agent id, assert signed GET still returns 200 and POST stays unblocked.
@rfxlamia
rfxlamia merged commit 8f49163 into main Jul 23, 2026
2 checks passed
@rfxlamia
rfxlamia deleted the fix/61-relay-p2-hardening branch July 23, 2026 03:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

relay P2: pair body limit, challenge rate-limit, allowlist parse fail-closed

1 participant